<

UI要素をドラッグする

ドラッグ アンド ドロップは、モバイル アプリの一般的な操作です。 ユーザーが長押しすると (「タッチ&ホールド) ウィジェット上では、その下に別のウィジェットが表示されます。 ユーザーの指でウィジェットをドラッグすると、 最終的な場所を指定して解放します。 このレシピでは、ドラッグ アンド ドロップ インタラクションを構築します ユーザーが食べ物の選択を長押しする場合、 そしてその食べ物を顧客の写真にドラッグします。 それを支払っているのです。

次のアニメーションはアプリの動作を示しています。

Ordering the food by dragging it to the person

このレシピは、事前に作成されたメニュー項目のリストから始まり、 お客さんの列。 最初のステップは長押しを認識することです ドラッグ可能なメニュー項目の写真を表示します。

押してドラッグする

Flutter は次のウィジェットを提供します。LongPressDraggable開始する必要がある正確な動作を提供します ドラッグアンドドロップ操作。あLongPressDraggableウィジェットは長押しが行われたことを認識し、 ユーザーの指の近くに新しいウィジェットを表示します。 ユーザーがドラッグすると、ウィジェットがユーザーの指に従います。LongPressDraggableを完全に制御できます ユーザーがドラッグするウィジェット。

メニューリストの各項目はカスタムで表示されます。MenuListItemウィジェット。

MenuListItem(
  name: item.name,
  price: item.formattedTotalItemPrice,
  photoProvider: item.imageProvider,
)

を包みますMenuListItemウィジェット付きLongPressDraggableウィジェット。

LongPressDraggable<Item>(
  data: item,
  dragAnchorStrategy: pointerDragAnchorStrategy,
  feedback: DraggingListItem(
    dragKey: _draggableKey,
    photoProvider: item.imageProvider,
  ),
  child: MenuListItem(
    name: item.name,
    price: item.formattedTotalItemPrice,
    photoProvider: item.imageProvider,
  ),
);

この場合、ユーザーが長押しすると、MenuListItemウィジェット、LongPressDraggableウィジェットにはDraggingListItem。 これDraggingListItemの写真を表示します 選択した食品項目(中央下) ユーザーの指。

dragAnchorStrategyプロパティはに設定されていますpointerDragAnchorStrategy。 このプロパティ値は、LongPressDraggableをベースにするDraggableListItemの位置 ユーザーの指。ユーザーが指を動かすと、 のDraggableListItemそれと一緒に動きます。

情報がなければ、ドラッグ アンド ドロップはほとんど役に立ちません。 アイテムを落とすと送信されます。 このために、LongPressDraggableかかりますdataパラメータ。 この場合の種類は、dataItem、 に関する情報を保持する ユーザーが押したフードメニュー項目。

dataに関連するLongPressDraggableという特別なウィジェットに送信されますDragTarget、 ユーザーがドラッグ ジェスチャを放した場所。 次にドロップ動作を実装します。

ドラッグ可能なものをドロップします

ユーザーはドロップできますLongPressDraggable彼らが選んだ場所はどこでも、 ただし、ドラッグ可能オブジェクトをドロップしても、ドロップされない限り効果はありません の上にDragTarget。ユーザーがドラッグ可能アイテムをドロップしたとき の上部DragTargetウィジェット、DragTargetウィジェット ドラッグ可能オブジェクトからのデータを受け入れるか拒否することができます。

このレシピでは、ユーザーはメニュー項目をCustomerCartメニュー項目をユーザーのカートに追加するウィジェット。

CustomerCart(
  hasItems: customer.items.isNotEmpty,
  highlighted: candidateItems.isNotEmpty,
  customer: customer,
);

を包みますCustomerCartウィジェット付きDragTargetウィジェット。

DragTarget<Item>(
  builder: (context, candidateItems, rejectedItems) {
    return CustomerCart(
      hasItems: customer.items.isNotEmpty,
      highlighted: candidateItems.isNotEmpty,
      customer: customer,
    );
  },
  onAccept: (item) {
    _itemDroppedOnCustomerCart(
      item: item,
      customer: customer,
    );
  },
)

DragTarget既存のウィジェットを表示し、 ともコーディネートしますd3821411-9fa1-4a7e-a​​a12-e7a8c0ae2880認めるために ユーザーがドラッグ可能オブジェクトをその上にドラッグすると、DragTarget。 のDragTargetユーザーが落下したときも認識します の上にあるドラッグ可能なDragTargetウィジェット。

ユーザーがドラッグ可能オブジェクトをドラッグすると、DragTargetウィジェット、candidateItemsユーザーがドラッグしているデータ項目が含まれます。 このドラッグ可能機能を使用すると、ウィジェットの外観を変更できます ユーザーがその上をドラッグしているときなどです。この場合、 のCustomerアイテムが上にドラッグされると、ウィジェットが赤くなります。DragTargetウィジェット。赤色の外観は、highlighted内のプロパティCustomerCartウィジェット。

ユーザーがドラッグ可能アイテムをドロップすると、DragTargetウィジェット、 のonAcceptコールバックが呼び出されます。これはあなたが得るときです ドロップされたデータを受け入れるかどうかを決定します。 この場合、アイテムは常に受け入れられ、処理されます。 受信したアイテムを検査して、 異なる決断。

ドロップされたアイテムのタイプに注目してください。DragTargetドラッグ元の項目のタイプと一致する必要がありますLongPressDraggable。 型に互換性がない場合は、 のonAcceptメソッドは呼び出されません。

とともにDragTargetを受け入れるように設定されたウィジェット 必要なデータを1つのパートから送信できるようになりました ドラッグ アンド ドロップして、UI を別の UI に移動します。

次のステップでは、 ドロップされたメニュー項目で顧客のカートを更新します。

メニュー項目をカートに追加する

各顧客は、Customer物体、 これは、商品のカートと合計価格を管理します。

class Customer {
  Customer({
    required this.name,
    required this.imageProvider,
    List<Item>? items,
  }) : items = items ?? [];

  final String name;
  final ImageProvider imageProvider;
  final List<Item> items;

  String get formattedTotalItemPrice {
    final totalPriceCents =
        items.fold<int>(0, (prev, item) => prev + item.totalPriceCents);
    return '\$${(totalPriceCents / 100.0).toStringAsFixed(2)}';
  }
}

CustomerCartウィジェットには顧客の写真が表示されます。 に基づく名前、合計、アイテム数Customer実例。

メニュー項目がドロップされたときに顧客のカートを更新するには、 ドロップされたアイテムを関連付けられたアイテムに追加しますCustomer物体。

void _itemDroppedOnCustomerCart({
  required Item item,
  required Customer customer,
}) {
  setState(() {
    customer.items.add(item);
  });
}

_itemDroppedOnCustomerCartメソッドが呼び出されるonAccept()ユーザーがメニュー項目をドロップしたときCustomerCartウィジェット。ドロップしたアイテムをcustomerオブジェクトと呼び出しsetState()を引き起こす レイアウトを更新すると、UI が新しい顧客の情報に合わせて更新されます。 合計金額とアイテム数。

おめでとう!ドラッグアンドドロップ操作がある 顧客のショッピング カートに食品を追加します。

インタラクティブな例

アプリを実行します。

  • 食品項目をスクロールします。
  • いずれかを押し続けます 指でクリックするか、 ねずみ。
  • 持っている間、食品の画像 リストの上に表示されます。
  • 画像をドラッグし、いずれかの場所にドロップします。 画面の下の方に人がいます。 画像の下のテキストが次のように更新されます。 その人の料金を反映します。 引き続き食品を追加できます そして料金が蓄積されるのを観察してください。
DartPadを開始する
import 'package:flutter/material.dart';

void main() {
  runApp(
    const MaterialApp(
      home: ExampleDragAndDrop(),
      debugShowCheckedModeBanner: false,
    ),
  );
}

const List<Item> _items = [
  Item(
    name: 'Spinach Pizza',
    totalPriceCents: 1299,
    uid: '1',
    imageProvider: NetworkImage('https://flutter'
        '.dev/docs/cookbook/img-files/effects/split-check/Food1.jpg'),
  ),
  Item(
    name: 'Veggie Delight',
    totalPriceCents: 799,
    uid: '2',
    imageProvider: NetworkImage('https://flutter'
        '.dev/docs/cookbook/img-files/effects/split-check/Food2.jpg'),
  ),
  Item(
    name: 'Chicken Parmesan',
    totalPriceCents: 1499,
    uid: '3',
    imageProvider: NetworkImage('https://flutter'
        '.dev/docs/cookbook/img-files/effects/split-check/Food3.jpg'),
  ),
];

@immutable
class ExampleDragAndDrop extends StatefulWidget {
  const ExampleDragAndDrop({super.key});

  @override
  State<ExampleDragAndDrop> createState() => _ExampleDragAndDropState();
}

class _ExampleDragAndDropState extends State<ExampleDragAndDrop>
    with TickerProviderStateMixin {
  final List<Customer> _people = [
    Customer(
      name: 'Makayla',
      imageProvider: const NetworkImage('https://flutter'
          '.dev/docs/cookbook/img-files/effects/split-check/Avatar1.jpg'),
    ),
    Customer(
      name: 'Nathan',
      imageProvider: const NetworkImage('https://flutter'
          '.dev/docs/cookbook/img-files/effects/split-check/Avatar2.jpg'),
    ),
    Customer(
      name: 'Emilio',
      imageProvider: const NetworkImage('https://flutter'
          '.dev/docs/cookbook/img-files/effects/split-check/Avatar3.jpg'),
    ),
  ];

  final GlobalKey _draggableKey = GlobalKey();

  void _itemDroppedOnCustomerCart({
    required Item item,
    required Customer customer,
  }) {
    setState(() {
      customer.items.add(item);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFF7F7F7),
      appBar: _buildAppBar(),
      body: _buildContent(),
    );
  }

  PreferredSizeWidget _buildAppBar() {
    return AppBar(
      iconTheme: const IconThemeData(color: Color(0xFFF64209)),
      title: Text(
        'Order Food',
        style: Theme.of(context).textTheme.headlineMedium?.copyWith(
              fontSize: 36,
              color: const Color(0xFFF64209),
              fontWeight: FontWeight.bold,
            ),
      ),
      backgroundColor: const Color(0xFFF7F7F7),
      elevation: 0,
    );
  }

  Widget _buildContent() {
    return Stack(
      children: [
        SafeArea(
          child: Column(
            children: [
              Expanded(
                child: _buildMenuList(),
              ),
              _buildPeopleRow(),
            ],
          ),
        ),
      ],
    );
  }

  Widget _buildMenuList() {
    return ListView.separated(
      padding: const EdgeInsets.all(16),
      itemCount: _items.length,
      separatorBuilder: (context, index) {
        return const SizedBox(
          height: 12,
        );
      },
      itemBuilder: (context, index) {
        final item = _items[index];
        return _buildMenuItem(
          item: item,
        );
      },
    );
  }

  Widget _buildMenuItem({
    required Item item,
  }) {
    return LongPressDraggable<Item>(
      data: item,
      dragAnchorStrategy: pointerDragAnchorStrategy,
      feedback: DraggingListItem(
        dragKey: _draggableKey,
        photoProvider: item.imageProvider,
      ),
      child: MenuListItem(
        name: item.name,
        price: item.formattedTotalItemPrice,
        photoProvider: item.imageProvider,
      ),
    );
  }

  Widget _buildPeopleRow() {
    return Container(
      padding: const EdgeInsets.symmetric(
        horizontal: 8,
        vertical: 20,
      ),
      child: Row(
        children: _people.map(_buildPersonWithDropZone).toList(),
      ),
    );
  }

  Widget _buildPersonWithDropZone(Customer customer) {
    return Expanded(
      child: Padding(
        padding: const EdgeInsets.symmetric(
          horizontal: 6,
        ),
        child: DragTarget<Item>(
          builder: (context, candidateItems, rejectedItems) {
            return CustomerCart(
              hasItems: customer.items.isNotEmpty,
              highlighted: candidateItems.isNotEmpty,
              customer: customer,
            );
          },
          onAccept: (item) {
            _itemDroppedOnCustomerCart(
              item: item,
              customer: customer,
            );
          },
        ),
      ),
    );
  }
}

class CustomerCart extends StatelessWidget {
  const CustomerCart({
    super.key,
    required this.customer,
    this.highlighted = false,
    this.hasItems = false,
  });

  final Customer customer;
  final bool highlighted;
  final bool hasItems;

  @override
  Widget build(BuildContext context) {
    final textColor = highlighted ? Colors.white : Colors.black;

    return Transform.scale(
      scale: highlighted ? 1.075 : 1.0,
      child: Material(
        elevation: highlighted ? 8 : 4,
        borderRadius: BorderRadius.circular(22),
        color: highlighted ? const Color(0xFFF64209) : Colors.white,
        child: Padding(
          padding: const EdgeInsets.symmetric(
            horizontal: 12,
            vertical: 24,
          ),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              ClipOval(
                child: SizedBox(
                  width: 46,
                  height: 46,
                  child: Image(
                    image: customer.imageProvider,
                    fit: BoxFit.cover,
                  ),
                ),
              ),
              const SizedBox(height: 8),
              Text(
                customer.name,
                style: Theme.of(context).textTheme.titleMedium?.copyWith(
                      color: textColor,
                      fontWeight:
                          hasItems ? FontWeight.normal : FontWeight.bold,
                    ),
              ),
              Visibility(
                visible: hasItems,
                maintainState: true,
                maintainAnimation: true,
                maintainSize: true,
                child: Column(
                  children: [
                    const SizedBox(height: 4),
                    Text(
                      customer.formattedTotalItemPrice,
                      style: Theme.of(context).textTheme.bodySmall!.copyWith(
                            color: textColor,
                            fontSize: 16,
                            fontWeight: FontWeight.bold,
                          ),
                    ),
                    const SizedBox(height: 4),
                    Text(
                      '${customer.items.length} item${customer.items.length != 1 ? 's' : ''}',
                      style: Theme.of(context).textTheme.titleMedium!.copyWith(
                            color: textColor,
                            fontSize: 12,
                          ),
                    ),
                  ],
                ),
              )
            ],
          ),
        ),
      ),
    );
  }
}

class MenuListItem extends StatelessWidget {
  const MenuListItem({
    super.key,
    this.name = '',
    this.price = '',
    required this.photoProvider,
    this.isDepressed = false,
  });

  final String name;
  final String price;
  final ImageProvider photoProvider;
  final bool isDepressed;

  @override
  Widget build(BuildContext context) {
    return Material(
      elevation: 12,
      borderRadius: BorderRadius.circular(20),
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Row(
          mainAxisSize: MainAxisSize.max,
          children: [
            ClipRRect(
              borderRadius: BorderRadius.circular(12),
              child: SizedBox(
                width: 120,
                height: 120,
                child: Center(
                  child: AnimatedContainer(
                    duration: const Duration(milliseconds: 100),
                    curve: Curves.easeInOut,
                    height: isDepressed ? 115 : 120,
                    width: isDepressed ? 115 : 120,
                    child: Image(
                      image: photoProvider,
                      fit: BoxFit.cover,
                    ),
                  ),
                ),
              ),
            ),
            const SizedBox(width: 30),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    name,
                    style: Theme.of(context).textTheme.titleMedium?.copyWith(
                          fontSize: 18,
                        ),
                  ),
                  const SizedBox(height: 10),
                  Text(
                    price,
                    style: Theme.of(context).textTheme.titleMedium?.copyWith(
                          fontWeight: FontWeight.bold,
                          fontSize: 18,
                        ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class DraggingListItem extends StatelessWidget {
  const DraggingListItem({
    super.key,
    required this.dragKey,
    required this.photoProvider,
  });

  final GlobalKey dragKey;
  final ImageProvider photoProvider;

  @override
  Widget build(BuildContext context) {
    return FractionalTranslation(
      translation: const Offset(-0.5, -0.5),
      child: ClipRRect(
        key: dragKey,
        borderRadius: BorderRadius.circular(12),
        child: SizedBox(
          height: 150,
          width: 150,
          child: Opacity(
            opacity: 0.85,
            child: Image(
              image: photoProvider,
              fit: BoxFit.cover,
            ),
          ),
        ),
      ),
    );
  }
}

@immutable
class Item {
  const Item({
    required this.totalPriceCents,
    required this.name,
    required this.uid,
    required this.imageProvider,
  });
  final int totalPriceCents;
  final String name;
  final String uid;
  final ImageProvider imageProvider;
  String get formattedTotalItemPrice =>
      '\$${(totalPriceCents / 100.0).toStringAsFixed(2)}';
}

class Customer {
  Customer({
    required this.name,
    required this.imageProvider,
    List<Item>? items,
  }) : items = items ?? [];

  final String name;
  final ImageProvider imageProvider;
  final List<Item> items;

  String get formattedTotalItemPrice {
    final totalPriceCents =
        items.fold<int>(0, (prev, item) => prev + item.totalPriceCents);
    return '\$${(totalPriceCents / 100.0).toStringAsFixed(2)}';
  }
}